Skip to content

fix(metadata): preserve Content-Length for fully buffered responses - #2703

Merged
james-elicx merged 4 commits into
cloudflare:mainfrom
tomvoss:fix/metadata-content-length
Jul 27, 2026
Merged

fix(metadata): preserve Content-Length for fully buffered responses#2703
james-elicx merged 4 commits into
cloudflare:mainfrom
tomvoss:fix/metadata-content-length

Conversation

@tomvoss

@tomvoss tomvoss commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

closeAfterResponseWithBody() wraps every body-bearing App/Pages Router
response in a TransformStream so it can release deferred after() work
once the body closes. That wrap strips Content-Length, even for a response
vinext has already fully materialized in memory.

Metadata file convention responses (robots(), sitemap(), manifest(),
static icons) always serialize their body to a string or byte array before
returning — no producer is left that could still call after(). This marks
those responses (markFullyBufferedBody) and skips the wrap when nothing is
currently registered, letting the runtime emit an accurate Content-Length
instead of chunked transfer encoding.

Arbitrary Route Handler responses, and a metadata route's Response
passthrough (a user icon.tsx/opengraph-image.tsx can return a streaming
ImageResponse), are left unmarked — their body's producer can still call
after() after the handler resolves, so a "nothing registered yet" check
can't prove them safe. Those stay close-tracked.

Test plan

  • tests/after-response-close-worker.test.ts (real Cloudflare Workers
    runtime via wrangler): robots.txt and sitemap.xml regain
    Content-Length; a hand-written Route Handler stays chunked; a Route
    Handler returning new Response(stream) still runs a late after() call
    correctly after the stream completes or is cancelled.
  • tests/shims.test.ts: unit coverage for the marker gate, and protective
    tests proving a late after() call from an unmarked stream waits for
    completion/cancellation and connects to ExecutionContext.waitUntil.
  • Targeted and nearby suites (metadata routes, after()/dispatch, App/Pages
    Router prod and dev servers) pass. vp check passes. Both packages build.
  • The full local suite has pre-existing timeout and cross-filesystem-rename
    (EXDEV) failures in this sandbox, unrelated to this change and
    reproducing identically on unmodified main; not fully green.

Limitations

Hand-written Route Handler responses stay close-tracked even when their
current body happens to be buffered, because a returned ReadableStream
may keep producing and register after() after the handler function
resolves. Safely optimizing those would need a separate, principled
buffered-body signal.

tomvoss and others added 2 commits July 25, 2026 02:11
closeAfterResponseWithBody() wraps every body-bearing response in a
TransformStream to support function-form after(), which strips
Content-Length even when the body is already fully materialized.
Metadata file convention responses (robots(), sitemap(), manifest(),
static icons) always serialize to a string or byte array with no
producer left, so mark those bodies (markFullyBufferedBody) and skip
the wrap when nothing is registered.

Arbitrary Route Handler responses and a metadata route's Response
passthrough stay wrapped: their body's producer may still call after()
after the handler resolves, so a "nothing registered yet" check can't
prove them safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqasUFjUPt2Q8gkrzrLGyB
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2703
npm i https://pkg.pr.new/create-vinext-app@2703
npm i https://pkg.pr.new/@vinext/types@2703
npm i https://pkg.pr.new/vinext@2703

commit: 6d11ff6

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff and traced the request lifecycle from metadata response construction through middleware rebuild, finalizeAppRscResponse, and closeAfterResponseWithBody. The core design is sound and the safety reasoning holds up under scrutiny:

Correctness of the skip gate

  • The requiresResponseCloseTracking() gate correctly captures all three ways after() work can be outstanding at wrap time: queued function-form callbacks (callbacks.length), in-flight ones (pendingCallbacks), and promise-form work whose continuation may register a callback later (pendingPromises). The resolveCompletion !== null belt-and-suspenders check handles re-entry after callbacks have started.
  • The key race is handled well: a user robots()/sitemap()/manifest() generator that itself calls after(promise) bumps pendingPromises synchronously during the awaited generator call, so by the time closeAfterResponseWithBody runs the skip is correctly not taken. The ported Next.js after-context test (tests/shims.test.ts:5093) exercises exactly this and is the strongest test in the set.
  • Marker survival was the thing I was most worried could regress silently. I verified: finalizeAppRscResponse mutates headers in-place and returns the same object (marker survives), config-header application mutates in-place (survives), and the one genuine rebuild — applyMiddlewareContextToResponse — is now guarded by preserveFullyBufferedBodyMetadata. The result instanceof Response passthroughs (metadata-route-response.ts:222, :307) are correctly left unmarked. No missed buffered-body construction sites.

Skip branch never calls closeAfterResponse — I confirmed this is safe: when the skip is taken, nothing is queued and no producer remains for a fully-buffered metadata body, so responseClosed staying false leaks nothing (no completion promise exists). This differs from the !response.body branch which does schedule a microtask close, but that difference is justified because the empty-body path can still receive a late registration whereas the skipped buffered-body path cannot.

Two minor, non-blocking observations below. Nice work — the doc comments and test coverage make the safety argument easy to follow.

type SitemapEntry,
} from "./metadata-routes.js";
import { notFoundResponse } from "./http-error-responses.js";
import { markFullyBufferedBody } from "vinext/shims/unified-request-context";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor style nit (non-blocking): this uses the bare vinext/shims/unified-request-context specifier while every other import in this file uses relative ./*.js paths (e.g. ./metadata-routes.js, ./http-error-responses.js). app-rsc-handler.ts also imports it via the bare specifier so this resolves fine and there's precedent — but for local consistency within this file a relative import would read cleaner. Not a blocker.

remote: false,
persist: false,
logLevel: "none",
watch: false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /robots.txt case is the interesting one because it goes through the middleware rebuild (new Response(response.body, ...)), so the final body handed to workerd is a ReadableStream, not a string. The assertion that Content-Length is preserved therefore depends on workerd being able to derive a fixed length from a stream sourced from an already-materialized Response.body. That's a runtime-specific behavior, so this real-workerd test is the right way to pin it. Worth keeping an eye on if workerd's compatibility date ever changes how new Response(stream) length inference works — the /sitemap.xml case (no middleware, string body reaches workerd directly) is the more robust guarantee. No change needed.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2703 (review)

Summary

I reviewed PR #2703 ("fix(metadata): preserve Content-Length for fully buffered responses") by tracing the full request lifecycle from metadata response construction through the middleware rebuild, finalizeAppRscResponse, and closeAfterResponseWithBody.

Verdict: sound, no blocking issues. This is a carefully-reasoned change with strong test coverage.

Key things I verified:

  1. The skip gate is correct. requiresResponseCloseTracking() catches all three ways after() work can be outstanding (queued callbacks, in-flight callbacks, promise-form work), plus a resolveCompletion !== null re-entry guard. The critical race — a user metadata generator calling after(promise) whose continuation registers a callback later — is handled because pendingPromises is bumped synchronously during the awaited generator call, so the skip is correctly refused.

  2. Marker survival across rebuilds (the highest silent-regression risk). finalizeAppRscResponse and config-header application mutate headers in-place (marker survives), and the one genuine new Response(...) rebuild path (applyMiddlewareContextToResponse) is now guarded by preserveFullyBufferedBodyMetadata. The result instanceof Response passthroughs are correctly left unmarked. No missed buffered-body sites.

  3. Skip branch not calling closeAfterResponse is safe — no producer remains for a fully-buffered metadata body, so nothing leaks.

Two minor non-blocking notes posted inline: an import-style inconsistency (bare vs relative specifier) and a note that the /robots.txt test depends on workerd's stream-length inference through the middleware rebuild (the /sitemap.xml string-body case is the more robust guarantee).

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared c5dcecc against base 05eee9f using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.4 KB ⚫ 0.0%
Client entry size (gzip) vinext 119.7 KB 119.7 KB ⚫ +0.0%
Dev server cold start vinext 2.82 s 2.82 s ⚫ +0.0%
Production build time vinext 3.09 s 3.10 s ⚫ +0.4%
RSC entry closure size (gzip) vinext 103.3 KB 103.4 KB ⚫ +0.1%
Server bundle size (gzip) vinext 177.8 KB 177.9 KB ⚫ +0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I traced the full request lifecycle from metadata response construction (markFullyBufferedBody) through the middleware rebuild (applyMiddlewareContextToResponse), finalizeAppRscResponse/config-header application, and into closeAfterResponseWithBody. The change is sound and I found no blocking issues. The safety argument holds and the test coverage is strong.

What I verified:

  1. Skip gate correctness. requiresResponseCloseTracking() covers all three outstanding-work forms (callbacks.length, pendingCallbacks, pendingPromises) plus the resolveCompletion !== null re-entry guard. The important race — a metadata generator that itself calls after(promise) whose continuation later registers a function-form callback — is handled two ways: pendingPromises is bumped synchronously inside the awaited generator (so the skip is refused at close time), and even if the promise settles early, the continuation's queueAfterCallback pushes to callbacks and sets resolveCompletion before the body is consumed. Confirmed after(promise) (server.ts:1247) does not call ensureAfterCompletion, so pure promise-form work relies on pendingPromises — which is correct.

  2. Marker survival across rebuilds. finalizeAppRscResponse mutates headers in place and returns the same object (marker survives); config-header application mutates in place; the one genuine new Response(...) rebuild on the metadata path (applyMiddlewareContextToResponse) is now guarded by preserveFullyBufferedBodyMetadata. The result instanceof Response passthroughs (metadata-route-response.ts:222 and withMetadataRouteCacheHeader at :98) are correctly left unmarked, since a user icon.tsx/opengraph-image.tsx can return a streaming ImageResponse. No missed buffered-body construction site.

  3. Skip branch never calls closeAfterResponse — safe, because a fully-materialized string/byte body has no remaining producer that could register late after() work, so nothing leaks.

Two non-blocking notes inline.

* bodies can still be producing and must keep close tracking.
*/
export function markFullyBufferedBody(response: Response): Response {
(response as ResponseWithFullyBufferedBodyMetadata).__vinextFullyBufferedBody = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: __vinextFullyBufferedBody becomes an own enumerable property on the Response instance handed to the runtime. This mirrors the existing __vinextStreamedHtmlResponse convention below (line 324), so it's consistent and Response objects aren't JSON-serialized or spread in practice — just flagging that the marker is technically observable on the wire object. No change needed.

type SitemapEntry,
} from "./metadata-routes.js";
import { notFoundResponse } from "./http-error-responses.js";
import { markFullyBufferedBody } from "vinext/shims/unified-request-context";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor style nit (non-blocking): this uses the bare vinext/shims/unified-request-context specifier while the other imports in this file are relative (./metadata-routes.js, ./http-error-responses.js). app-rsc-handler.ts already imports it via the bare specifier so this resolves fine and there's precedent, but a relative ./ import would read more consistently within this file.

});
expect(res.status).toBe(200);
expect(res.headers.get("content-encoding")).toBeNull();
expect(res.headers.get("x-metadata-middleware")).toBe("applied");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /robots.txt assertion is the more fragile of the two Content-Length cases: it goes through the middleware rebuild (new Response(response.body, ...)), so the body handed to workerd is a ReadableStream sourced from an already-materialized body, and the preserved Content-Length depends on workerd inferring a fixed length from that stream. That's runtime-specific behavior worth watching if the compatibility date ever changes stream-length inference. The /sitemap.xml case (no middleware matcher, string body reaches workerd directly) is the more robust guarantee — good that both are covered.

@ask-bonk

ask-bonk Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully: #2703 (review)

Summary

I reviewed PR #2703 ("fix(metadata): preserve Content-Length for fully buffered responses") by tracing the full request lifecycle from metadata response construction through the middleware rebuild, finalizeAppRscResponse/config-header application, and into closeAfterResponseWithBody.

Verdict: sound, no blocking issues. Carefully reasoned change with strong test coverage.

Key things I verified:

  1. Skip gate is correct. requiresResponseCloseTracking() catches all three ways after() work can be outstanding (queued callbacks, in-flight callbacks, promise-form work) plus a resolveCompletion re-entry guard. The critical race — a metadata generator calling after(promise) whose continuation later registers a callback — is handled because pendingPromises is bumped synchronously during the awaited generator, and the continuation's queueAfterCallback sets callbacks/resolveCompletion before the body is consumed. Confirmed promise-form after() doesn't call ensureAfterCompletion, so it correctly relies on pendingPromises.

  2. Marker survives rebuilds (the highest silent-regression risk). finalizeAppRscResponse and config-header application mutate headers in place; the one genuine new Response(...) rebuild (applyMiddlewareContextToResponse) is now guarded by preserveFullyBufferedBodyMetadata. The result instanceof Response passthroughs are correctly left unmarked. No missed buffered-body site.

  3. Skip branch not calling closeAfterResponse is safe — no producer remains for a fully-materialized body.

Three non-blocking notes posted inline: the marker property is technically observable on the wire object (consistent with existing __vinextStreamedHtmlResponse convention), an import-style inconsistency (bare vs relative specifier), and the /robots.txt test's dependency on workerd stream-length inference through the middleware rebuild (the /sitemap.xml string-body case being the more robust guarantee).

My review largely corroborates the earlier ask-bonk review and adds the marker-observability observation.

github run

@james-elicx
james-elicx enabled auto-merge (squash) July 27, 2026 12:03
@james-elicx
james-elicx merged commit 0b58bbc into cloudflare:main Jul 27, 2026
55 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants